pg_procrustes
A fast, flexible PostgreSQL formatter in Go. Driven by the native PostgreSQL parser, it understands your code like the database itself.
Go
PostgreSQL
MIT
The name
In Greek mythology, Procrustes was a bandit who operated an iron bed on the road to Athens.
He invited travellers to spend the night — then made sure they fit the bed perfectly.
If a guest was too tall, he cut off their legs. If too short, he stretched them until they matched.
He actually kept two beds: one small, one large — so no guest could ever escape adjustment.
Either way, everyone fit.
pg_procrustes works the same way. You define the bed — the formatting rules — via
.pg_procrustes.yaml. Your SQL will fit. Whether it likes it or not.
Installation
Homebrew (macOS and Linux)
brew install heptau/tap/pg-procrustes
Binary download
Download the archive for your platform from GitHub Releases, extract, and place the binary on your PATH.
go install (requires Go toolchain)
go install github.com/heptau/pg_procrustes/cmd/pg_procrustes@latest
Build from source
# current platform
make build
# raw binaries for all platforms
make build-all
Editor integration
This repository doubles as a Neovim plugin. Requires the pg_procrustes binary on your PATH (see Installation above).
Neovim
With lazy.nvim:
-- lazy.nvim
{
"heptau/pg_procrustes",
ft = "sql",
config = function()
require("pg_procrustes").setup()
end,
}
With packer.nvim:
-- packer.nvim
use({
"heptau/pg_procrustes",
config = function()
require("pg_procrustes").setup()
end,
})
If conform.nvim is installed, sql buffers get pg_procrustes registered as a formatter automatically —
require("conform").format() and format-on-save pick it up like any other formatter. Without conform.nvim, use :PgProcrustesFormat to format the current buffer directly.
Run :checkhealth pg_procrustes to verify the setup, or :help pg_procrustes for the full reference.
Zed
Zed has built-in support for external formatters — no extension needed. Add this to your project settings (.zed/settings.json) or user settings (~/.config/zed/settings.json):
// .zed/settings.json
{
"languages": {
"SQL": {
"formatter": {
"external": {
"command": "pg_procrustes",
"arguments": []
}
}
}
}
}
Then format with the editor: format command, or set "format_on_save": "on".
VS Code
VS Code has no built-in "external formatter" option, but the
Custom Local Formatters
extension adds one purely through settings — no extension of your own required. Install it, then add:
// settings.json
"customLocalFormatters.formatters": [
{
"command": "pg_procrustes",
"languages": ["sql"]
}
],
"[sql]": {
"editor.defaultFormatter": "jkillian.custom-local-formatters"
}
Format Document (Shift+Alt+F) and editor.formatOnSave will now run pg_procrustes.
DataGrip & other JetBrains IDEs
DataGrip bundles the File Watchers plugin (enable it under Settings → Tools → File Watchers if it's off). Add a custom watcher:
The file is reformatted in place on every save. For a manual, on-demand trigger instead, use Settings → Tools → External Tools with the same Program/Arguments.
Emacs
With apheleia.el (async, keeps point position):
;; init.el
(setf (alist-get 'pg-procrustes apheleia-formatters) '("pg_procrustes"))
(setf (alist-get 'sql-mode apheleia-mode-alist) 'pg-procrustes)
(add-hook 'sql-mode-hook #'apheleia-mode)
Or with the lighter reformatter.el:
;; init.el
(reformatter-define pg-procrustes-format
:program "pg_procrustes")
(add-hook 'sql-mode-hook #'pg-procrustes-format-on-save-mode)
Either gives you format-on-save without writing a package of your own.
Helix
Add to ~/.config/helix/languages.toml (or a project-local .helix/languages.toml):
# languages.toml
[[language]]
name = "sql"
formatter = { command = "pg_procrustes" }
auto-format = true
Format on demand with :format, or let auto-format run it on every save.
Sublime Text
Install Fmt via Package Control, then add to its settings (Preferences → Package Settings → Fmt → Settings):
// Fmt.sublime-settings
{
"rules": [
{
"selector": "source.sql",
"cmd": ["pg_procrustes"],
"format_on_save": true,
"merge_type": "diff"
}
]
}
Or trigger it manually from the command palette with Fmt: Format Buffer.
Vim (via ALE)
With ALE installed, define a fixer in your .vimrc:
" .vimrc
let g:ale_fixers = {
\ 'sql': [
\ {buffer -> {'command': 'pg_procrustes'}},
\ ],
\}
let g:ale_fix_on_save = 1
Or run it on demand with :ALEFix.
Zed, VS Code, DataGrip, Emacs, Helix, Sublime Text, and Vim above are config-only integrations — no dedicated plugin required, though one may follow later. If your editor launches as a GUI app rather than from a terminal, it may not see your shell's PATH; use the absolute path from which pg_procrustes if the command isn't found.
Automation & CI
pre-commit
Add a pre-commit local hook (requires pg_procrustes on PATH — see Installation) to .pre-commit-config.yaml:
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: pg_procrustes
name: pg_procrustes
language: system
entry: pg_procrustes --check
files: \.sql$
--check fails the commit without touching files, so you review the diff and run pg_procrustes -w yourself. Prefer autofix-on-commit instead? Swap the entry for pg_procrustes -w — pre-commit detects that files were modified and still fails that run, so you can review, re-stage, and commit again.
GitHub Actions
A minimal CI job that fails a pull request if any tracked .sql file isn't formatted:
# .github/workflows/pg_procrustes.yml
name: SQL formatting
on: [pull_request]
jobs:
pg_procrustes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: go install github.com/heptau/pg_procrustes/cmd/pg_procrustes@latest
- run: pg_procrustes --check $(git ls-files '*.sql')
Swap the go install step for the Homebrew or binary download methods if you'd rather not depend on a Go toolchain in CI.
Usage
# format a file in place
pg_procrustes -w query.sql
pg_procrustes -w migrations/*.sql
# print formatted output to stdout
pg_procrustes query.sql
# read from stdin, write to stdout
cat query.sql | pg_procrustes
# CI mode — exit 1 if any file would change
pg_procrustes --check *.sql
# show a unified diff without writing
pg_procrustes --diff query.sql
# show version
pg_procrustes --version
Flags
Config file lookup
pg_procrustes looks for .pg_procrustes.yaml starting in the current directory and walking up to the filesystem root.
Use -c to specify a path explicitly. All settings default to preserve — if no config is found, nothing changes.
Keyword & identifier casing
Each token class can be set to upper, lower, or preserve.
reserved_keywords:
case: upper # SELECT, FROM, WHERE, JOIN, …
keywords:
case: upper # ANALYZE, CASCADE, VERBOSE, …
data_types:
case: lower # integer, text, timestamp, …
form: long # long | short | long_no_space | preserve
literals:
case: upper # TRUE, FALSE, NULL
operators:
case: upper # AND, OR, NOT, IN, IS, LIKE, BETWEEN, …
schemas: { case: lower }
tables: { case: lower }
columns: { case: lower }
functions: { case: lower }
conditional_functions: { case: upper } # COALESCE, NULLIF, GREATEST, LEAST
system_functions: { case: upper } # CURRENT_DATE, SESSION_USER, …
aliases:
case: lower
as: add # add | preserve | remove
Casing exceptions
Any casing section accepts an exceptions list. Words in the list keep their exact capitalisation regardless of the section's case setting.
reserved_keywords:
case: upper
exceptions:
- select # stays lowercase even though case: upper
- from
columns:
case: lower
exceptions:
- ID # stays "ID" instead of "id"
- CreatedAt
functions:
case: lower
exceptions:
- MyFunc
Exceptions are matched case-insensitively — the value in the list defines the exact output casing.
Data type forms
PL/pgSQL variables & keywords
Two dedicated sections for tokens that the PostgreSQL scanner classifies as NO_KEYWORD — they are invisible to the standard keywords / reserved_keywords rules.
plpgsql_variables
Runtime pseudo-variables available inside trigger and function bodies:
plpgsql_variables:
case: upper # upper | lower | preserve
# Covers: NEW, OLD, EXCLUDED (ON CONFLICT),
# FOUND, TG_OP, TG_TABLE_NAME, TG_TABLE_SCHEMA,
# TG_NAME, TG_WHEN, TG_LEVEL, TG_NARGS, TG_ARGV,
# TG_RELID, TG_RELNAME, TG_EVENT, TG_TAG,
# SQLSTATE, SQLERRM, ROW_COUNT, PG_CONTEXT,
# PG_EXCEPTION_DETAIL, PG_EXCEPTION_HINT, PG_EXCEPTION_CONTEXT,
# RETURNED_SQLSTATE, MESSAGE_TEXT, PG_DATATYPE_NAME, PG_ROUTINE_OID
Before
IF tg_op = 'INSERT' THEN
INSERT INTO audit(tbl, op, new_id)
VALUES(tg_table_name, tg_op, new.id);
END IF;
RETURN new;
After — plpgsql_variables: upper
IF TG_OP = 'INSERT' THEN
INSERT INTO audit(tbl, op, new_id)
VALUES(TG_TABLE_NAME, TG_OP, NEW.id);
END IF;
RETURN NEW;
plpgsql_keywords
PL/pgSQL statement keywords not classified as SQL keywords by the scanner:
plpgsql_keywords:
case: upper # upper | lower | preserve
# Covers: RAISE, PERFORM, ELSIF, ELSEIF, FOREACH, REVERSE,
# SLICE, EXIT, LOOP, WHILE, OPEN, ASSERT,
# DEBUG, INFO, NOTICE, WARNING, EXCEPTION (as RAISE severity / handler)
Before
loop
exit when i >= n;
raise notice 'i=%', i;
i := i + 1;
end loop;
After — plpgsql_keywords: upper
LOOP
EXIT WHEN i >= n;
RAISE NOTICE 'i=%', i;
i := i + 1;
END LOOP;
Both sections accept an exceptions list — words matching an exception keep their original capitalisation.
Punctuation & spacing
trailing_whitespace: strip # strip | preserve
semicolons: preserve # preserve | add | remove
inequality_op: c # preserve | ansi (<>) | c (!=)
join_form: preserve # preserve | short | long
operator_spacing: normalize # preserve | normalize | compact
comma_spacing: normalize # preserve | normalize | compact
blank_lines: preserve # preserve | max_3 | max_2 | max_1
paren_spacing: remove # preserve | add | remove
quoted_identifiers: remove_safe
schema_qualification: preserve # preserve | remove_public
cast_style: preserve # preserve | operator (CAST(x AS t) → x::t)
order_asc: preserve # preserve | add | remove
not_in: preserve # preserve | not_in | not_equals_all
Layout
The layout section controls clause-level line breaking and indentation. All modes default to preserve.
layout:
line_length: 128
indent:
size: 3 # spaces per level (ignored when type: tab)
type: spaces # spaces | tab
normalize: preserve
remainder: keep # keep | add | remove | round
union:
blank_line: preserve # preserve | none | before | after | both
paren_indent:
mode: preserve # preserve | indent | none
close_first_on_line: same # same | after
union.blank_line
Controls blank lines around UNION, UNION ALL, INTERSECT, and EXCEPT.
paren_indent
mode: indent re-indents content inside multi-line parenthesised blocks (subqueries, IN (…)) to exactly N spaces per depth level. close_first_on_line: after places the closing ) on its own line at the inner indentation level.
Clause breaking
clauses.break: auto puts each SQL clause on its own line when the flat statement exceeds line_length. Breaking is all-or-none.
Individual clauses can override the default:
layout:
clauses:
break: auto # preserve | never | always | auto
align: same # same | indent
join: { break: always }
limit: { break: never }
Before
SELECT u.id, u.name FROM users u INNER JOIN orders o ON o.user_id = u.id WHERE u.active = TRUE ORDER BY u.name
After — break: auto, line_length: 80
SELECT u.id, u.name
FROM users u
INNER JOIN orders o ON o.user_id = u.id
WHERE u.active = TRUE
ORDER BY u.name
Content breaking
Controls how items inside a clause are laid out. SELECT and RETURNING split at commas; WHERE, HAVING, and JOIN conditions split at AND/OR (the operator is placed at the start of the continuation line).
Break modes
first_item
first_item controls where the first item goes when breaking is active. Independent of the break mode.
layout:
content:
break: auto # global default for all sections
align: indent # same | indent
first_item: break # break | inline
# per-section overrides — each inherits content settings when omitted:
select_list: { break: always, first_item: inline } # SELECT column list (comma)
where_conds: { break: always, first_item: inline } # WHERE conditions (AND/OR)
having_conds: { break: always, first_item: inline } # HAVING conditions (AND/OR)
join_on: { break: always } # JOIN ON conditions (AND/OR)
group_list: { break: auto } # GROUP BY items (comma)
order_list: { break: auto } # ORDER BY items (comma)
set_list: { break: always } # UPDATE SET assignments (comma)
insert_columns: { break: always, first_item: inline } # INSERT INTO table(col1, col2, …)
values_list: { break: always } # VALUES row tuples
returning_list: { break: always, first_item: inline } # RETURNING items (comma)
with_list: { break: always } # WITH CTE definitions (comma)
break: always + first_item
first_item: break (default)
SELECT
u.id,
u.name,
u.email
WHERE
u.active = TRUE
AND u.type = 'premium'
first_item: inline
SELECT u.id,
u.name,
u.email
WHERE u.active = TRUE
AND u.type = 'premium'
INSERT column list
insert_columns controls the (col1, col2, …) list after the table name in INSERT statements. For always the opening paren gets its own line; for first_inline the first column stays on the paren line.
insert_columns: always
INSERT INTO users (
id,
name,
email
)
VALUES (...)
insert_columns: first_inline
INSERT INTO users (id,
name,
email
)
VALUES (...)
WITH clause
with_list: always puts each CTE definition on its own indented line:
with_list: always
WITH
active_users AS (SELECT id, name FROM users WHERE active = TRUE),
recent_orders AS (SELECT user_id, count(*) FROM orders GROUP BY user_id)
SELECT * FROM active_users
CASE expressions
Controls SQL CASE … END expressions in SELECT, WHERE, and other clauses.
layout:
case:
break: preserve # preserve | never | always | auto
indent: indent # preserve | none | indent
Before
SELECT
CASE WHEN status = 'active'
THEN 1
WHEN status = 'pending'
THEN 0
ELSE -1
END
FROM t
After — break: never
SELECT CASE WHEN status = 'active' THEN 1 WHEN status = 'pending' THEN 0 ELSE -1 END
FROM t
PL/pgSQL formatting
Dollar-quoted function and procedure bodies are formatted recursively. The layout.dollar_quote.plpgsql section controls block structure.
layout:
dollar_quote:
newline_after_open: preserve # preserve | add | remove
newline_before_close: preserve
sql: # for LANGUAGE sql bodies
body_indent: preserve # preserve | none | indent
blank_line_before: preserve # blank line after opening $$
blank_line_after: preserve # blank line before closing $$
plpgsql:
keyword_indent: preserve # preserve | none | indent (DECLARE/BEGIN/END)
declare_when_empty: preserve # preserve | add | remove
end_semicolon: preserve # preserve | add | remove
declare:
indent: preserve
blank_line_before: preserve
blank_line_after: preserve
begin_body:
indent: preserve
blank_line_before: preserve
blank_line_after: preserve
PL/pgSQL — IF blocks
layout:
dollar_quote:
plpgsql:
control_flow:
if:
body_indent: preserve # preserve | none | indent
blank_line_before: preserve # preserve | add | remove
blank_line_after: preserve
Before
BEGIN
IF x = 1 THEN
y := 'a';
ELSIF x = 2 THEN
y := 'b';
ELSE
y := 'c';
END IF;
END
After — body_indent: indent
BEGIN
IF x = 1 THEN
y := 'a';
ELSIF x = 2 THEN
y := 'b';
ELSE
y := 'c';
END IF;
END
PL/pgSQL — LOOP blocks
Covers plain LOOP, FOR i IN … LOOP, FOR rec IN SELECT … LOOP, and WHILE cond LOOP. The loop header is preserved verbatim.
layout:
dollar_quote:
plpgsql:
control_flow:
loop:
body_indent: preserve
blank_line_before: preserve
blank_line_after: preserve
Before
BEGIN
FOR i IN 1..10 LOOP
total := total + i;
END LOOP;
END
After — body_indent: indent, blank_line_after: add
BEGIN
FOR i IN 1..10 LOOP
total := total + i;
END LOOP;
END
PL/pgSQL — CASE statements
Two independent configs: simple (value switch) and searched (condition switch).
layout:
dollar_quote:
plpgsql:
control_flow:
case:
simple: # CASE expr WHEN value THEN …
when_indent: preserve # preserve | none | indent
then_break: preserve # preserve | never | always | auto
then_indent: preserve # preserve | none | indent
body_break: preserve # preserve | never | always | auto
body_indent: preserve # preserve | none | indent
blank_line_before: preserve
blank_line_after: preserve
searched: # CASE WHEN condition THEN …
# same settings as simple
Compact style
BEGIN
CASE status
WHEN 'active' THEN result := 1;
WHEN 'pending' THEN result := 0;
ELSE result := -1;
END CASE;
END
Expanded style
BEGIN
CASE
WHEN status = 'active'
THEN
result := 1;
WHEN status = 'pending'
THEN
result := 0;
ELSE
result := -1;
END CASE;
END